Correcao Sidebar Classico e Rail, Correcao Layout, Ajuste de Breakpoint para Tailwind, Ajuste AppTopbar, Ajuste Menu PopOver, Recriado Paleta de Cores, Inserido algumas animações leves, Reajuste Cor items NOVOS da tabela, Drawer Ajuda Corrigido no Logout, Whatsapp, sms, email, recursos extras
This commit is contained in:
@@ -15,558 +15,484 @@
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import {
|
||||
createSupportSession,
|
||||
listActiveSupportSessions,
|
||||
listSessionHistory,
|
||||
revokeSupportSession,
|
||||
buildSupportUrl,
|
||||
} from '@/support/supportSessionService'
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { createSupportSession, listActiveSupportSessions, listSessionHistory, revokeSupportSession, buildSupportUrl } from '@/support/supportSessionService';
|
||||
|
||||
const TAG = '[SaasSupportPage]'
|
||||
const toast = useToast()
|
||||
const TAG = '[SaasSupportPage]';
|
||||
const toast = useToast();
|
||||
|
||||
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||
const activeTab = ref(0)
|
||||
const activeTab = ref(0);
|
||||
|
||||
// ── Estado — Nova Sessão ──────────────────────────────────────────────────────
|
||||
const selectedTenantId = ref(null)
|
||||
const ttlMinutes = ref(60)
|
||||
const sessionNote = ref('')
|
||||
const creating = ref(false)
|
||||
const generatedUrl = ref(null)
|
||||
const generatedData = ref(null)
|
||||
const selectedTenantId = ref(null);
|
||||
const ttlMinutes = ref(60);
|
||||
const sessionNote = ref('');
|
||||
const creating = ref(false);
|
||||
const generatedUrl = ref(null);
|
||||
const generatedData = ref(null);
|
||||
|
||||
// ── Estado — Listas ───────────────────────────────────────────────────────────
|
||||
const loadingTenants = ref(false)
|
||||
const loadingSessions = ref(false)
|
||||
const loadingHistory = ref(false)
|
||||
const revokingToken = ref(null)
|
||||
const loadingTenants = ref(false);
|
||||
const loadingSessions = ref(false);
|
||||
const loadingHistory = ref(false);
|
||||
const revokingToken = ref(null);
|
||||
|
||||
const tenants = ref([])
|
||||
const tenantMap = ref({})
|
||||
const activeSessions = ref([])
|
||||
const sessionHistory = ref([])
|
||||
const tenants = ref([]);
|
||||
const tenantMap = ref({});
|
||||
const activeSessions = ref([]);
|
||||
const sessionHistory = ref([]);
|
||||
|
||||
// ── TTL Options ───────────────────────────────────────────────────────────────
|
||||
const ttlOptions = [
|
||||
{ label: '15 minutos', value: 15 },
|
||||
{ label: '30 minutos', value: 30 },
|
||||
{ label: '1 hora', value: 60 },
|
||||
{ label: '2 horas', value: 120 },
|
||||
]
|
||||
{ label: '15 minutos', value: 15 },
|
||||
{ label: '30 minutos', value: 30 },
|
||||
{ label: '1 hora', value: 60 },
|
||||
{ label: '2 horas', value: 120 }
|
||||
];
|
||||
|
||||
// ── Countdown tick ────────────────────────────────────────────────────────────
|
||||
const _now = ref(Date.now())
|
||||
let _tickTimer = null
|
||||
const _now = ref(Date.now());
|
||||
let _tickTimer = null;
|
||||
|
||||
function startTick () {
|
||||
if (_tickTimer) return
|
||||
_tickTimer = setInterval(() => { _now.value = Date.now() }, 10_000)
|
||||
function startTick() {
|
||||
if (_tickTimer) return;
|
||||
_tickTimer = setInterval(() => {
|
||||
_now.value = Date.now();
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => { if (_tickTimer) clearInterval(_tickTimer) })
|
||||
onBeforeUnmount(() => {
|
||||
if (_tickTimer) clearInterval(_tickTimer);
|
||||
});
|
||||
|
||||
// ── Computed ──────────────────────────────────────────────────────────────────
|
||||
const expiresLabel = computed(() => {
|
||||
if (!generatedData.value?.expires_at) return ''
|
||||
return new Date(generatedData.value.expires_at).toLocaleString('pt-BR')
|
||||
})
|
||||
if (!generatedData.value?.expires_at) return '';
|
||||
return new Date(generatedData.value.expires_at).toLocaleString('pt-BR');
|
||||
});
|
||||
|
||||
const tokenPreview = computed(() => {
|
||||
if (!generatedData.value?.token) return ''
|
||||
const t = generatedData.value.token
|
||||
return `${t.slice(0, 8)}…${t.slice(-8)}`
|
||||
})
|
||||
if (!generatedData.value?.token) return '';
|
||||
const t = generatedData.value.token;
|
||||
return `${t.slice(0, 8)}…${t.slice(-8)}`;
|
||||
});
|
||||
|
||||
const activeSessionCount = computed(() => activeSessions.value.length)
|
||||
const activeSessionCount = computed(() => activeSessions.value.length);
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
console.log(`${TAG} montado`)
|
||||
await loadTenants()
|
||||
await loadActiveSessions()
|
||||
startTick()
|
||||
})
|
||||
console.log(`${TAG} montado`);
|
||||
await loadTenants();
|
||||
await loadActiveSessions();
|
||||
startTick();
|
||||
});
|
||||
|
||||
// ── Tenants ───────────────────────────────────────────────────────────────────
|
||||
async function loadTenants () {
|
||||
loadingTenants.value = true
|
||||
console.log(`${TAG} loadTenants`)
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('tenants')
|
||||
.select('id, name, kind')
|
||||
.order('name', { ascending: true })
|
||||
async function loadTenants() {
|
||||
loadingTenants.value = true;
|
||||
console.log(`${TAG} loadTenants`);
|
||||
try {
|
||||
const { data, error } = await supabase.from('tenants').select('id, name, kind').order('name', { ascending: true });
|
||||
|
||||
if (error) throw error
|
||||
if (error) throw error;
|
||||
|
||||
const list = data || []
|
||||
console.log(`${TAG} ${list.length} tenant(s) carregado(s)`)
|
||||
const list = data || [];
|
||||
console.log(`${TAG} ${list.length} tenant(s) carregado(s)`);
|
||||
|
||||
tenantMap.value = Object.fromEntries(list.map(t => [t.id, t.name || t.id]))
|
||||
tenants.value = list.map(t => ({
|
||||
value: t.id,
|
||||
label: `${t.name} (${t.kind ?? 'tenant'})`,
|
||||
}))
|
||||
} catch (e) {
|
||||
console.error(`${TAG} loadTenants ERRO`, e)
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 })
|
||||
} finally {
|
||||
loadingTenants.value = false
|
||||
}
|
||||
tenantMap.value = Object.fromEntries(list.map((t) => [t.id, t.name || t.id]));
|
||||
tenants.value = list.map((t) => ({
|
||||
value: t.id,
|
||||
label: `${t.name} (${t.kind ?? 'tenant'})`
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error(`${TAG} loadTenants ERRO`, e);
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
|
||||
} finally {
|
||||
loadingTenants.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sessões ativas ─────────────────────────────────────────────────────────────
|
||||
async function loadActiveSessions () {
|
||||
loadingSessions.value = true
|
||||
console.log(`${TAG} loadActiveSessions`)
|
||||
try {
|
||||
activeSessions.value = await listActiveSupportSessions()
|
||||
console.log(`${TAG} ${activeSessions.value.length} sessão(ões) ativa(s)`)
|
||||
} catch (e) {
|
||||
console.error(`${TAG} loadActiveSessions ERRO`, e)
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 })
|
||||
} finally {
|
||||
loadingSessions.value = false
|
||||
}
|
||||
async function loadActiveSessions() {
|
||||
loadingSessions.value = true;
|
||||
console.log(`${TAG} loadActiveSessions`);
|
||||
try {
|
||||
activeSessions.value = await listActiveSupportSessions();
|
||||
console.log(`${TAG} ${activeSessions.value.length} sessão(ões) ativa(s)`);
|
||||
} catch (e) {
|
||||
console.error(`${TAG} loadActiveSessions ERRO`, e);
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
|
||||
} finally {
|
||||
loadingSessions.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Histórico ─────────────────────────────────────────────────────────────────
|
||||
async function loadHistory () {
|
||||
if (loadingHistory.value) return
|
||||
loadingHistory.value = true
|
||||
console.log(`${TAG} loadHistory`)
|
||||
try {
|
||||
sessionHistory.value = await listSessionHistory(100)
|
||||
console.log(`${TAG} histórico: ${sessionHistory.value.length} registro(s)`)
|
||||
} catch (e) {
|
||||
console.error(`${TAG} loadHistory ERRO`, e)
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 })
|
||||
} finally {
|
||||
loadingHistory.value = false
|
||||
}
|
||||
async function loadHistory() {
|
||||
if (loadingHistory.value) return;
|
||||
loadingHistory.value = true;
|
||||
console.log(`${TAG} loadHistory`);
|
||||
try {
|
||||
sessionHistory.value = await listSessionHistory(100);
|
||||
console.log(`${TAG} histórico: ${sessionHistory.value.length} registro(s)`);
|
||||
} catch (e) {
|
||||
console.error(`${TAG} loadHistory ERRO`, e);
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
|
||||
} finally {
|
||||
loadingHistory.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Criar sessão ──────────────────────────────────────────────────────────────
|
||||
async function handleCreate () {
|
||||
if (!selectedTenantId.value) return
|
||||
creating.value = true
|
||||
generatedUrl.value = null
|
||||
generatedData.value = null
|
||||
async function handleCreate() {
|
||||
if (!selectedTenantId.value) return;
|
||||
creating.value = true;
|
||||
generatedUrl.value = null;
|
||||
generatedData.value = null;
|
||||
|
||||
console.log(`${TAG} handleCreate`, { tenantId: selectedTenantId.value, ttlMinutes: ttlMinutes.value, note: sessionNote.value || '(sem nota)' })
|
||||
console.log(`${TAG} handleCreate`, { tenantId: selectedTenantId.value, ttlMinutes: ttlMinutes.value, note: sessionNote.value || '(sem nota)' });
|
||||
|
||||
try {
|
||||
const result = await createSupportSession(selectedTenantId.value, ttlMinutes.value, sessionNote.value)
|
||||
generatedData.value = result
|
||||
generatedUrl.value = buildSupportUrl(result.token)
|
||||
try {
|
||||
const result = await createSupportSession(selectedTenantId.value, ttlMinutes.value, sessionNote.value);
|
||||
generatedData.value = result;
|
||||
generatedUrl.value = buildSupportUrl(result.token);
|
||||
|
||||
console.log(`${TAG} sessão criada com sucesso`, { token: `${result.token.slice(0,8)}…`, expires_at: result.expires_at })
|
||||
console.log(`${TAG} sessão criada com sucesso`, { token: `${result.token.slice(0, 8)}…`, expires_at: result.expires_at });
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Sessão criada', detail: 'URL de suporte gerada com sucesso.', life: 4000 })
|
||||
await loadActiveSessions()
|
||||
} catch (e) {
|
||||
console.error(`${TAG} handleCreate ERRO`, e)
|
||||
toast.add({ severity: 'error', summary: 'Erro ao criar sessão', detail: e?.message, life: 5000 })
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
toast.add({ severity: 'success', summary: 'Sessão criada', detail: 'URL de suporte gerada com sucesso.', life: 4000 });
|
||||
await loadActiveSessions();
|
||||
} catch (e) {
|
||||
console.error(`${TAG} handleCreate ERRO`, e);
|
||||
toast.add({ severity: 'error', summary: 'Erro ao criar sessão', detail: e?.message, life: 5000 });
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Revogar ───────────────────────────────────────────────────────────────────
|
||||
async function handleRevoke (token) {
|
||||
revokingToken.value = token
|
||||
console.log(`${TAG} handleRevoke token=${token.slice(0, 8)}…`)
|
||||
try {
|
||||
await revokeSupportSession(token)
|
||||
toast.add({ severity: 'success', summary: 'Sessão revogada', life: 3000 })
|
||||
if (generatedData.value?.token === token) {
|
||||
generatedUrl.value = null
|
||||
generatedData.value = null
|
||||
async function handleRevoke(token) {
|
||||
revokingToken.value = token;
|
||||
console.log(`${TAG} handleRevoke token=${token.slice(0, 8)}…`);
|
||||
try {
|
||||
await revokeSupportSession(token);
|
||||
toast.add({ severity: 'success', summary: 'Sessão revogada', life: 3000 });
|
||||
if (generatedData.value?.token === token) {
|
||||
generatedUrl.value = null;
|
||||
generatedData.value = null;
|
||||
}
|
||||
await loadActiveSessions();
|
||||
if (sessionHistory.value.length) await loadHistory();
|
||||
} catch (e) {
|
||||
console.error(`${TAG} handleRevoke ERRO`, e);
|
||||
toast.add({ severity: 'error', summary: 'Erro ao revogar', detail: e?.message, life: 4000 });
|
||||
} finally {
|
||||
revokingToken.value = null;
|
||||
}
|
||||
await loadActiveSessions()
|
||||
if (sessionHistory.value.length) await loadHistory()
|
||||
} catch (e) {
|
||||
console.error(`${TAG} handleRevoke ERRO`, e)
|
||||
toast.add({ severity: 'error', summary: 'Erro ao revogar', detail: e?.message, life: 4000 })
|
||||
} finally {
|
||||
revokingToken.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Copiar ────────────────────────────────────────────────────────────────────
|
||||
function copyUrl (url) {
|
||||
if (!url) return
|
||||
navigator.clipboard.writeText(url)
|
||||
console.log(`${TAG} URL copiada`)
|
||||
toast.add({ severity: 'info', summary: 'Copiado!', detail: 'URL copiada para a área de transferência.', life: 2000 })
|
||||
function copyUrl(url) {
|
||||
if (!url) return;
|
||||
navigator.clipboard.writeText(url);
|
||||
console.log(`${TAG} URL copiada`);
|
||||
toast.add({ severity: 'info', summary: 'Copiado!', detail: 'URL copiada para a área de transferência.', life: 2000 });
|
||||
}
|
||||
|
||||
// ── Tab change ────────────────────────────────────────────────────────────────
|
||||
function onTabChange (e) {
|
||||
const idx = e.index ?? e
|
||||
activeTab.value = idx
|
||||
console.log(`${TAG} tab mudou para ${idx}`)
|
||||
if (idx === 2 && sessionHistory.value.length === 0) loadHistory()
|
||||
function onTabChange(e) {
|
||||
const idx = e.index ?? e;
|
||||
activeTab.value = idx;
|
||||
console.log(`${TAG} tab mudou para ${idx}`);
|
||||
if (idx === 2 && sessionHistory.value.length === 0) loadHistory();
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
function tenantName (id) {
|
||||
return tenantMap.value[id] || id
|
||||
function tenantName(id) {
|
||||
return tenantMap.value[id] || id;
|
||||
}
|
||||
|
||||
function formatDate (iso) {
|
||||
if (!iso) return '-'
|
||||
return new Date(iso).toLocaleString('pt-BR')
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '-';
|
||||
return new Date(iso).toLocaleString('pt-BR');
|
||||
}
|
||||
|
||||
function remainingLabel (iso) {
|
||||
_now.value // dependência reativa
|
||||
if (!iso) return '-'
|
||||
const diff = new Date(iso) - Date.now()
|
||||
if (diff <= 0) return 'Expirada'
|
||||
const min = Math.floor(diff / 60000)
|
||||
const h = Math.floor(min / 60)
|
||||
const m = min % 60
|
||||
if (h > 0) return `${h}h ${m}min`
|
||||
return `${min} min`
|
||||
function remainingLabel(iso) {
|
||||
_now.value; // dependência reativa
|
||||
if (!iso) return '-';
|
||||
const diff = new Date(iso) - Date.now();
|
||||
if (diff <= 0) return 'Expirada';
|
||||
const min = Math.floor(diff / 60000);
|
||||
const h = Math.floor(min / 60);
|
||||
const m = min % 60;
|
||||
if (h > 0) return `${h}h ${m}min`;
|
||||
return `${min} min`;
|
||||
}
|
||||
|
||||
function isExpiringSoon (iso) {
|
||||
if (!iso) return false
|
||||
const diff = (new Date(iso) - Date.now()) / 60000
|
||||
return diff > 0 && diff < 15
|
||||
function isExpiringSoon(iso) {
|
||||
if (!iso) return false;
|
||||
const diff = (new Date(iso) - Date.now()) / 60000;
|
||||
return diff > 0 && diff < 15;
|
||||
}
|
||||
|
||||
function sessionStatusSeverity (session) {
|
||||
if (session._expired) return 'danger'
|
||||
if (isExpiringSoon(session.expires_at)) return 'warning'
|
||||
return 'success'
|
||||
function 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'
|
||||
function sessionStatusLabel(session) {
|
||||
if (session._expired) return 'Expirada';
|
||||
if (isExpiringSoon(session.expires_at)) return 'Expirando';
|
||||
return 'Ativa';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Sentinel -->
|
||||
<div class="h-px" />
|
||||
|
||||
<!-- Sentinel -->
|
||||
<div class="h-px" />
|
||||
|
||||
<!-- Hero sticky -->
|
||||
<div
|
||||
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
|
||||
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
||||
>
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-orange-400/10" />
|
||||
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-fuchsia-400/10" />
|
||||
<!-- Hero sticky -->
|
||||
<div class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-orange-400/10" />
|
||||
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-fuchsia-400/10" />
|
||||
</div>
|
||||
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] flex-shrink-0">
|
||||
<i class="pi pi-headphones text-[var(--text-color)]" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Suporte Técnico</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Gere e gerencie links seguros de acesso em modo debug</div>
|
||||
</div>
|
||||
</div>
|
||||
<Tag v-if="activeSessionCount > 0" :value="`${activeSessionCount} sessão${activeSessionCount > 1 ? 'ões' : ''} ativa${activeSessionCount > 1 ? 's' : ''}`" severity="warning" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] flex-shrink-0">
|
||||
<i class="pi pi-headphones text-[var(--text-color)]" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Suporte Técnico</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Gere e gerencie links seguros de acesso em modo debug</div>
|
||||
</div>
|
||||
</div>
|
||||
<Tag
|
||||
v-if="activeSessionCount > 0"
|
||||
:value="`${activeSessionCount} sessão${activeSessionCount > 1 ? 'ões' : ''} ativa${activeSessionCount > 1 ? 's' : ''}`"
|
||||
severity="warning"
|
||||
/>
|
||||
|
||||
<!-- content -->
|
||||
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
|
||||
<!-- 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="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="text-[1rem] font-semibold flex items-center gap-2 mb-4">
|
||||
<i class="pi pi-plus-circle text-[var(--primary-color)]" />
|
||||
Configurar acesso de suporte
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-[1rem] 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-[1rem] 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-[1rem] font-medium">
|
||||
Nota / Motivo
|
||||
<span class="text-[var(--text-color-secondary)] 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="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="text-[1rem] font-semibold flex items-center gap-2 mb-4">
|
||||
<i class="pi pi-link text-[var(--primary-color)]" />
|
||||
URL Gerada
|
||||
</div>
|
||||
|
||||
<div v-if="generatedUrl" class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-[1rem] font-medium">Link de Acesso</label>
|
||||
<div class="flex gap-2">
|
||||
<InputText :value="generatedUrl" readonly class="flex-1 font-mono text-[1rem]" />
|
||||
<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-[1rem]">
|
||||
<i class="pi pi-clock text-orange-500" />
|
||||
<span class="text-[var(--text-color-secondary)]">Expira em:</span>
|
||||
<strong>{{ expiresLabel }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-[1rem] text-[var(--text-color-secondary)] font-mono">
|
||||
<i class="pi pi-key" />
|
||||
<span>{{ tokenPreview }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="sessionNote" class="flex items-start gap-2 text-[1rem] text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-comment mt-0.5 flex-shrink-0" />
|
||||
<span class="italic">{{ sessionNote }}</span>
|
||||
</div>
|
||||
|
||||
<Message severity="info" :closable="false">
|
||||
<div class="text-[1rem]">Envie este link ao terapeuta ou acesse diretamente para monitorar os logs da agenda em tempo real.</div>
|
||||
</Message>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col items-center justify-center py-12 text-[var(--text-color-secondary)] gap-3">
|
||||
<i class="pi pi-shield text-4xl opacity-25" />
|
||||
<div class="text-[1rem]">Nenhuma sessão gerada ainda</div>
|
||||
</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="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 mt-2">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-[1rem] font-semibold flex items-center gap-2">
|
||||
<i class="pi pi-circle-fill text-green-500" />
|
||||
Sessões em vigor
|
||||
</div>
|
||||
<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-[1rem]">{{ tenantName(data.tenant_id) }}</span>
|
||||
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.tenant_id }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Token">
|
||||
<template #body="{ data }">
|
||||
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ 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-[1rem] text-[var(--text-color-secondary)]">{{ formatDate(data.created_at) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Nota">
|
||||
<template #body="{ data }">
|
||||
<span v-if="data._note" class="text-[1rem] italic text-[var(--text-color-secondary)]">{{ data._note }}</span>
|
||||
<span v-else class="text-[1rem] text-[var(--text-color-secondary)]">—</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="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 mt-2">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-[1rem] font-semibold flex items-center gap-2">
|
||||
<i class="pi pi-history text-[var(--primary-color)]" />
|
||||
Últimas 100 sessões
|
||||
</div>
|
||||
<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)" />
|
||||
</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-[1rem]">{{ tenantName(data.tenant_id) }}</span>
|
||||
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.tenant_id }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Token">
|
||||
<template #body="{ data }">
|
||||
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.token.slice(0, 12) }}…</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Criada em" sortable field="created_at">
|
||||
<template #body="{ data }">
|
||||
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ formatDate(data.created_at) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Expirava em">
|
||||
<template #body="{ data }">
|
||||
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ formatDate(data.expires_at) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Nota">
|
||||
<template #body="{ data }">
|
||||
<span v-if="data._note" class="text-[1rem] italic text-[var(--text-color-secondary)]">{{ data._note }}</span>
|
||||
<span v-else class="text-[1rem] text-[var(--text-color-secondary)]">—</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>
|
||||
</div>
|
||||
|
||||
<!-- content -->
|
||||
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
|
||||
|
||||
<!-- 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="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="text-[1rem] font-semibold flex items-center gap-2 mb-4">
|
||||
<i class="pi pi-plus-circle text-[var(--primary-color)]" />
|
||||
Configurar acesso de suporte
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-[1rem] 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-[1rem] 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-[1rem] font-medium">
|
||||
Nota / Motivo
|
||||
<span class="text-[var(--text-color-secondary)] 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="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="text-[1rem] font-semibold flex items-center gap-2 mb-4">
|
||||
<i class="pi pi-link text-[var(--primary-color)]" />
|
||||
URL Gerada
|
||||
</div>
|
||||
|
||||
<div v-if="generatedUrl" class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-[1rem] font-medium">Link de Acesso</label>
|
||||
<div class="flex gap-2">
|
||||
<InputText :value="generatedUrl" readonly class="flex-1 font-mono text-[1rem]" />
|
||||
<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-[1rem]">
|
||||
<i class="pi pi-clock text-orange-500" />
|
||||
<span class="text-[var(--text-color-secondary)]">Expira em:</span>
|
||||
<strong>{{ expiresLabel }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-[1rem] text-[var(--text-color-secondary)] font-mono">
|
||||
<i class="pi pi-key" />
|
||||
<span>{{ tokenPreview }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="sessionNote" class="flex items-start gap-2 text-[1rem] text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-comment mt-0.5 flex-shrink-0" />
|
||||
<span class="italic">{{ sessionNote }}</span>
|
||||
</div>
|
||||
|
||||
<Message severity="info" :closable="false">
|
||||
<div class="text-[1rem]">Envie este link ao terapeuta ou acesse diretamente para monitorar os logs da agenda em tempo real.</div>
|
||||
</Message>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col items-center justify-center py-12 text-[var(--text-color-secondary)] gap-3">
|
||||
<i class="pi pi-shield text-4xl opacity-25" />
|
||||
<div class="text-[1rem]">Nenhuma sessão gerada ainda</div>
|
||||
</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="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 mt-2">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-[1rem] font-semibold flex items-center gap-2">
|
||||
<i class="pi pi-circle-fill text-green-500" />
|
||||
Sessões em vigor
|
||||
</div>
|
||||
<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-[1rem]">{{ tenantName(data.tenant_id) }}</span>
|
||||
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.tenant_id }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Token">
|
||||
<template #body="{ data }">
|
||||
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ 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-[1rem] text-[var(--text-color-secondary)]">{{ formatDate(data.created_at) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Nota">
|
||||
<template #body="{ data }">
|
||||
<span v-if="data._note" class="text-[1rem] italic text-[var(--text-color-secondary)]">{{ data._note }}</span>
|
||||
<span v-else class="text-[1rem] text-[var(--text-color-secondary)]">—</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="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 mt-2">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-[1rem] font-semibold flex items-center gap-2">
|
||||
<i class="pi pi-history text-[var(--primary-color)]" />
|
||||
Últimas 100 sessões
|
||||
</div>
|
||||
<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)" />
|
||||
</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-[1rem]">{{ tenantName(data.tenant_id) }}</span>
|
||||
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.tenant_id }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Token">
|
||||
<template #body="{ data }">
|
||||
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.token.slice(0, 12) }}…</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Criada em" sortable field="created_at">
|
||||
<template #body="{ data }">
|
||||
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ formatDate(data.created_at) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Expirava em">
|
||||
<template #body="{ data }">
|
||||
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ formatDate(data.expires_at) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Nota">
|
||||
<template #body="{ data }">
|
||||
<span v-if="data._note" class="text-[1rem] italic text-[var(--text-color-secondary)]">{{ data._note }}</span>
|
||||
<span v-else class="text-[1rem] text-[var(--text-color-secondary)]">—</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